Skip to content

Quickshell on Kandelo — Qt 6.10 on wasm32: QtCore, QtGui, QML, and a theme gallery that restyles the desktop - #1354

Draft
mho22 wants to merge 19 commits into
explore-dri-omarchyfrom
explore-dri-quattro
Draft

Quickshell on Kandelo — Qt 6.10 on wasm32: QtCore, QtGui, QML, and a theme gallery that restyles the desktop#1354
mho22 wants to merge 19 commits into
explore-dri-omarchyfrom
explore-dri-quattro

Conversation

@mho22

@mho22 mho22 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Why

Omarchy v4 folds Waybar, Walker, Mako, SwayOSD, hyprlock, hypridle, swaybg and polkit-gnome into a single Quickshell QML process, and Quickshell is a QtQuick client. Nothing Qt existed in the registry, so this PR is the floor of that stack: Qt 6.10.2 QtCore, QtConcurrent, QtXml, QtGui and the Wayland platform plugin, static on wasm32, running on the kernel.

It stacks on explore-dri-omarchy (#1241), extending the DRI stack (#698#709#948#976#1318#1241this). The staging plan lands whole: stage 0 is the Qt6 host toolchain, stage 1 is QtCore, stage 2 is QtGui plus the Wayland plugin, stage 3 is QtDeclarative running QML on the kernel, stage 4 is Quickshell 0.3.1, and stage 5 puts Quickshell on the Omarchy desktop as a launcher entry. A final pass sizes the whole stack to the browser's per-worker wasm compile cost and replaces the first Qt demo with a theme gallery that restyles the live desktop.

What changed

QtCore on wasm32 (stage 1). Qt is the first dependency that cannot use the meson bypass — moc, rcc and uic codegen is woven through qtbase, so it drives its own CMake with host generators from the flake's pinned Qt through QT_HOST_PATH. Three target adaptations, each for a reason the port exposed: -D__linux__=1 because qsystemdetection.h has no wasm branch and reads preprocessor macros, not CMAKE_SYSTEM_NAME; QT_LINUXBASE plus qmutex-honour-qt-linuxbase.patch because Qt 6.10.2 honours that switch in qfutex_p.h but ignores it in qmutex.h, so the configuration static-asserts without the patch; and an empty <linux/fs.h> because Qt defines the one symbol it wants (FICLONE) when the header does not. QProcess starts off at this stage: its forkfd references _Fork, and musl's _Fork.o also carries __post_Fork, which channel_syscall.c does not override, so linking duplicates _Fork. The QProcess paragraph below settles this before stage 4. Qt also baked its configure-time prefix into libQt6Core.a as qt_prfxpath, writing the builder's PID into every downstream program; the recipe now configures against the guest path, relocates at install time, and fails if the staging path survives.

The host qtwaylandscanner (stage 2's build prerequisite). Every Qt Wayland cross-build runs a host qtwaylandscanner. qtbase builds it only if(LINUX), so nixpkgs' darwin qtbase ships neither the generator nor its Qt6WaylandScannerTools CMake package — stage 2 would build on Linux CI and fail on every Mac. flake.nix now builds the generator from qtbase's own src and version, joined into QT_HOST_PATH only where nixpkgs' qtbase declines wayland, so Linux keeps qtbase's own copy and receives no duplicate. The binary stays out of every package recipe on purpose: a package archive is content-hashed and published, so a Mach-O binary inside one makes an arm64 Mac and an x86_64 runner produce different wasm32 archives for the same source.

QtGui and the Wayland platform plugin (stage 2). Qt 6.10 moved the Wayland client platform plugin out of qtwayland and into qtbase, at src/plugins/platforms/wayland. No qtwayland package is needed — the one qtbase recipe now produces libQt6WaylandClient.a, the wayland QPA plugin and the xdg-shell integration, driven by the host scanner above. QtGui's dependencies come from the registry: freetype, fontconfig, harfbuzz, libpng and libxkbcommon. OpenGL stays off, so the client runs on wl_shm — QtQuick will need a graphics API; that is stage 3. One cross-build trap fixed on the way: write_basic_package_version_file bakes the pointer width of the machine that built the host tool into a bitness check, so the wasm32 consumer found the package and refused it ("version: 6.10.2 (64bit)"); clearing CMAKE_SIZEOF_VOID_P omits the check, as Qt's own tools packages do.

The gate is a program, not a recipe assertion. qt_gui_smoke.cpp imports the offscreen, wayland and xdg-shell plugins, asserts both Wayland keys reach the static plugin registry, fills a rect through the raster engine and typesets a string through fontconfig, FreeType and HarfBuzz, on the kernel:

QT_VERSION=6.10.2
PLATFORM=offscreen
WAYLAND_PLUGIN=yes
XDG_SHELL_PLUGIN=yes
CORNER=ff000000  CENTRE=ffff0000
GLYPH_PIXELS=228
QT_GUI_SMOKE_OK

The build script also rejects any env. import outside the runtime's own three — the SDK link does not fail on an undefined symbol, it leaves a host import that traps only if that path runs, which is how a missing libxml2 surfaced as Unimplemented import: env.xmlCreatePushParserCtxt at runtime rather than at link. Every archive on the link line was then removed in turn: what remains is what the check or the run demands.

Font enumeration is not claimed. fontconfig matches the staged font and FreeType rasterises it (GLYPH_PIXELS=228), but QFontDatabase reports only Qt's three generic families, never the staged name, so the gate asserts ink and says so in its comment.

qtgallery — the theme gallery is the Qt window on the desktop. The stack above proves itself only offscreen; qtgallery puts Qt's pixels on the Omarchy desktop and gives the visitor something to do. It reads every installed theme (a directory under /usr/share/kandelo/themes with a theme.conf) and paints each as a card — a miniature desktop rendered from that theme's own palette: wallpaper gradient, bar strip, two tiled windows with the active border. Clicking a card writes dispatch theme <name> to the compositor's kwlctl socket, the same control surface CTRL+SHIFT+Space and hyprctl use, and the whole desktop repaints from the new palette. A second card row lists the staged Quickshell shells (shell.qml, island.qml, status.qml); activating one live-swaps the running Quickshell's QML (the compile-cost section below explains how). The app is data-driven — drop a theme directory in the VFS and its card appears — and a card activates by pointer click (the path Qt clients had never exercised) or by arrows and Enter. The desktop's Qt window went in first as qtdemo, a wave-field-and-clock demo; the gallery replaces it, program and gates. Two findings from that first window hold: Qt's wayland plugin resolves the socket through XDG_RUNTIME_DIR and refuses to start without it, and the omarchy fonts.conf gains a sans-serif alias to the staged Inconsolata, because Qt asks for sans-serif where the wpkdraw clients ask for monospace.

Both hosts gate it. host/test/qtgallery-smoke.test.ts boots wlcompositor and qtgallery under one NodeKernelHost, stages two fixture themes, injects a real pointer click on the first card, and reads the compositor's THEME line back — the click-to-restyle loop end to end — then a host-injected Escape closes the window through Qt's xkb state and both processes exit 0. The browser spec (kandelo-omarchy.spec.ts) launches it the way a user does — Ctrl+Space, "ga", Enter — and asserts GALLERY_PLATFORM=wayland, GALLERY_THEMES n=6, the sixth tile, and the kernel-ABI check.

Qt paints through gbm pools — the invisible-window bug and its gate. The first browser run of the desktop's Qt window passed every protocol marker and drew nothing: pure wallpaper where its tile focused. On this kernel a wl_shm pool fd must be a DRI prime fd — the browser compositor composites with its GL renderer, which imports client pools with gbm_bo_import(GBM_BO_IMPORT_FD); Qt's stock backing store allocates a memfd, the import fails EINVAL, and repaint_gl skips the surface while map, focus, tile and the frame-callback loop all keep reporting success. The Node smoke never sees it because the Node compositor is on the CPU renderer, which reads any fd. wayland-shm-gbm-pool.patch backs QWaylandShmBuffer with a renderD128 gbm bo — allocate, gbm_bo_map persistently into the QImage at bo stride, hand gbm_bo_get_fd() to wl_shm_create_pool — the same adaptation foot and GTK already carry.

The class now has its own gate, because every existing one is protocol, not pixels. wlcompositor prints a one-shot GLDRAW app_id= the first time the GL renderer draws a window's own texture, and the browser spec polls it for foot and qtgallery: a window that maps, focuses, tiles and pumps frames but never draws now fails the spec, and the fix was verified by screenshot — the Qt window's pixels on the canvas, not wallpaper.

QML runs on the kernel (stage 3). qtshadertools cross-builds first — qtdeclarative's CMake hard-requires it even though the software adaptation never compiles a shader at runtime — and qtdeclarative builds QtQml, QtQmlModels and QtQuick with the software scenegraph selected via QT_QUICK_BACKEND=software. One patch carries the stage: qv4-stack-bounds-on-wasm.patch bounds the QV4 engine's stack probes, because the engine derives its native stack base from platform heuristics that do not hold on wasm32 and unpatched it walks past the linear-memory stack into the heap. The qt-qml-smoke gate loads a QML scene on the offscreen platform and asserts rendered non-background pixels through QQuickWindow::grabWindow. Quickshell later widened the stage: qtbase adds QtWidgets (QuickControls2 styles link it) and QtNetwork (src/ipc links Qt::Network unconditionally), and qtdeclarative re-enables qml-network and qml-debug because Quickshell's launcher calls QQmlDebuggingEnabler unguarded. QtNetwork builds with brotli forced off — qtbase's configure auto-detected brotli from the nix host closure and emitted -lbrotlidec with no wasm32 bytes behind it, the host-leak class PR #406 diagnosed.

A MAP_FIXED map inside a mapping carves, not evicts — a kernel fix QML forced. The MAP_FIXED branch of mmap dropped every mapping that overlapped the requested range, including the parts outside it, so a fixed map placed inside a larger mapping erased the whole mapping from the manager's records and a later first-fit search could hand out addresses the program still used. QtQml hit exactly this: its guard-page mprotect path re-maps a page inside the QML stack area, and the stack then got reallocated under the running interpreter. The branch now routes through munmap, which already splits a mapping and keeps the remainder recorded; test_map_fixed_inside_mapping_splits_it pins the carve.

QProcess through forkfd's generic fork fallback. Quickshell needs QProcess, and stage 1 had switched it off. Qt is compiled with __linux__ defined, which routes its bundled forkfd to forkfd_linux.c, whose clone(2) calls cannot exist on this kernel — and whose libc clone() reference pulls musl's _Fork.o through __post_Fork, duplicating the glue's _Fork at link. forkfd-generic-on-wasm.patch keeps wasm off that path with one condition (__linux__ && !__wasm__); forkfd then takes its generic fork() fallback — the same instrumented fork every shell pipeline uses — and reaps through waitid(WNOWAIT) and SIGCHLD, both of which the kernel carries. libc/glue stays untouched. The gui smoke gains a QProcess pass: the program re-execs itself with --child through QProcess::start and asserts the child's exit code and marker.

Quickshell 0.3.1 on wasm32 (stage 4). Quickshell is the QtQuick shell toolkit Omarchy v4 writes its bar and launcher in. The build keeps WAYLAND with WLR_LAYERSHELL and turns off every feature whose dependency this kernel does not carry (DBus, PipeWire, PAM, greetd, X11, Hyprland, screencopy, session-lock, toplevel-management, jemalloc, the crash handler). cli11 2.5.0 ships as a new header-only package for the launcher's CLI parsing. no-wl-proxy-interpose-on-wasm.patch removes upstream's wl_proxy_get_listener interposition — a dynamic-linking mechanism (dlsym RTLD_NEXT plus --export-dynamic-symbol, which wasm-ld lacks) that collides with libwayland-client.a's definition in a static link. Support commits carry the surrounding surface: libdrm installs a relocatable .pc and the linux/asm UAPI shims a __linux__-defining consumer needs, wayland-protocols stages upstream 1.45's canonical share/wayland-protocols/ layout for CMake's pkg_get_variable, and the executable link closes over libffi, libxml2, libiconv, libcharset and the sysroot gbm — the recipe had declared them without linking them, and the resulting env.ffi_prep_cif trap killed quickshell inside libwayland's closure marshalling before the QML engine started. After the fix the only env imports are the three the platform owns.

Every spawn compiled its program twice — the fix that let Quickshell start on Firefox. The SYS_SPAWN preflight resolver (handlePosixSpawnResolve, both hosts) compiled a module that the shared worker's resolveSpawnProgram always discarded, then compileSpawnCandidateSnapshot compiled the same bytes again. The snapshot compile already carries every check the resolver compile provided. On Firefox the duplicate is what broke the desktop: SpiderMonkey reserves one fixed 2 GiB executable-code region per content process for all workers (verified with vmmap — a single 2.0G rwx VM_ALLOCATE region), compiled wasm code is several times the module size (the 93 MB quickshell.wasm costs ~620 MB of arena, measured), and with the desktop's live code at ~1.2 GB the second quickshell compile could not get code memory — InternalError: out of memory, mapped to EIO, surfaced as posix_spawnp: I/O error. The resolver contract narrows to ResolvedSpawnCandidate — bytes and argv, no module — so the duplicate cannot come back. Both host entries drop their WebAssembly.compile; the candidate-binding tests assert byte-identical commits reuse the snapshot module and diverging commits recompile from the committed bytes.

Quickshell joins the desktop (stage 5). The launcher gains a Quickshell entry: Quickshell runs the staged /usr/share/kandelo/quickshell/island.qml — a floating clock island reads better over the wallpaper than a full-width bar under Waybar's — with shell.qml and status.qml staged beside it, swappable live from the gallery, and the compositor's environment carries QT_QUICK_BACKEND=software for every client it spawns. Two infrastructure fixes made the boot survive: the rootfs superblock capacity doubles to 512 MiB (the omarchy demo stages ~200 MB of desktop binaries; the 256 MiB cap died with ENOSPC at exec-time materialization of /usr/bin/dash), and the local-build engine now exports WASM_POSIX_SOURCE_ONLY_BINARY_ROOT to recipes, so a recipe that boots the kernel host — coreutils-docs runs the real coreutils.wasm — resolves under the source-only policy; ./run.sh local-build completes 151/151 nodes from cold.

The spec grows Gate 5e: launch Quickshell through the launcher and take the LAYER ns=quickshell line as the mapping proof — layer surfaces never emit GLDRAW. The gate runs on Chromium and WebKit. Firefox is excluded with a pointer to the new docs/browser-support.md section: after the double-compile fix Quickshell spawns and runs QML on Firefox, but its first pthread_create needs the thread-patched module — a second legitimate full compile — and ~1.9 GB of live desktop code plus another ~620 MB cannot fit the 2 GiB arena, so QThread::start fails. Disabling the optimizing JIT does not reduce the arena below the cap. That is an engine limit, not host waste — every byte of that code belongs to a running desktop program — so the section records the measured numbers and the spec gates on it.

The browser's per-worker compile cost bounds the Qt stack — shrink, single-thread, never respawn. Chromium compiles a wasm module's machine code once per Web Worker — a worker is a separate V8 isolate, and isolates do not share compiled code even for the same posted WebAssembly.Module. Every guest pthread is one worker, so each thread a Qt process starts costs another full compiled copy of quickshell.wasm (~93 MB of wasm) on top of the running desktop. Everything here attacks that one term. qtbase, qtdeclarative and quickshell build MinSizeRel instead of Release — code size multiplies across workers, so size beats speed. qtdeclarative turns qml-type-loader-thread off, taking Qt's supported synchronous type-loading path (the same shape as upstream's single-threaded wasm port) — the loader thread cost a full compiled copy of every QML consumer and its per-reload churn spiked tab memory on each Quickshell config reload. A wayland-fd-notifier patch (qtbase) and an on-thread logger patch (quickshell) remove threads that existed only to block on fds. And the gallery's shell swap retires the respawn path entirely: it stages the selected shell as /tmp/qtgallery-active.qml through QSaveFile (the atomic commit keeps Quickshell's content-hash reload from reading a half-written file) and one long-lived Quickshell child live-reloads it in place — respawn churn at this module size crashed the tab before the previous copy's code memory was reclaimed. Verified in Chromium: three shell-card swaps plus a 35 s hold, no crash, worker count stable at 13. A Playwright gate for the swap is deliberately not added — a CI gate that re-enters the old crash window on every run would flake; the probe run above is the record. docs/browser-support.md#quickshell-qml-limits is rewritten around the measured facts: single-threading the Qt client roughly halved the baseline, and the QML content decides how much cold Qt/ICU code each worker compiles.

The emitted fork instrumentation shrinks to what a landing actually needs. Call-heavy Qt functions came out of wasm-fork-instrument too large for TurboFan's register allocator, and giant functions made the liveness pass itself blow up. Four bounds fix both, without changing what a frame can replay. Scalar liveness now decides what a frame saves: only locals live on a successor of a fork landing round-trip through the frame, and both dataflow passes share one bitset representation — a BTreeSet per node across thousands of locals costs gigabytes, one bit per tracked local costs megabytes. Spill locals come from one function-wide pool keyed by storage type and per-landing ordinal, so instrument-added locals are bounded by the widest single landing instead of the landing count. From eight fork-path calls a function trades the per-site unwind boundaries (kept below the threshold — they preserve recursion depth, PR #713) for one shared frame-select handler, replacing roughly fifteen boundary instructions per site with a static call index. And the reference save/restore dispatch emits one body per distinct (slots, nulls) set instead of one body per run. crates/fork-instrument/tests/shared_boundary_node.rs runs an instrumented nine-landing fixture in Node through a real unwind/rewind and an abort-restart with an injected reservation failure; dedicated cases pin that a dead scalar stays out of the frame and that the fork-abort fixtures keep their live payloads.

inotify fails honestly (kernel). inotify_init returned a real pollable fd (an eventfd in disguise) that never delivers events, and add/rm_watch pretended to succeed — the exact combination that defeats every watcher that only takes its polling fallback when inotify_init fails. Qt's QFileSystemWatcher and glib's GFileMonitor trusted the dead fd and never saw a change. All three entry points now return ENOSYS, the stub eventfd helper and its tests go away, and docs/posix-status.md matches the behavior it already claimed. programs/inotify-enosys.c plus host/test/inotify-enosys.test.ts assert the errno for each entry point through the real syscall path.

One Terminal, and a launcher that scrolls. The launcher listed three terminals — Terminal (wlterm), Bash, Foot — when one is the product: the entry is now Terminal and it execs stock upstream foot, the strongest of the three (an unmodified Wayland client through fontconfig and fcft). Eight entries remain: Clock, Nano, NetHack, Paint, Quickshell, Terminal, Theme Gallery, Vim. The old separate Foot gate folds into the Terminal gate, which keeps the exec, ABI and GLDRAW assertions. klauncher gains a scroll offset clamped to the surface height in render() and reset on level entry — rows past the window bottom were silently undrawn, which made Theme Gallery unreachable by arrow keys.

A recipe that boots the kernel declares the kernel. generate-coreutils-man.ts ran coreutils inside Kandelo by resolving kernel.wasm through the checkout-wide binary resolver — but a package build runs inside the aggregate, and the projection authority the resolver would consult is only finalized after every package (including this one) has succeeded. The kernel is now a declared package dependency of coreutils-docs, the build script passes the dependency dir's kandelo-kernel.wasm to the capture script explicitly, and the test helper accepts kernel bytes as an option instead of always resolving them itself.

Verification

All runs inside scripts/dev-shell.sh. Both hosts are covered: the Node side through the vitest smokes (qtgallery-smoke, quickshell-smoke, qt-gui-smoke, qt-qml-smoke, inotify-enosys) and the browser side through the Playwright omarchy spec on all three engines — the parallel spawn/exec/exit wiring in node-kernel-worker-entry.ts and browser-kernel-worker-entry.ts changes symmetrically (the resolver-contract narrowing above).

Resolution env: the fork-instrument rework changes every fork-instrumented package's cache key, so the published archives no longer match. Until CI publishes the new-key binaries from the committed build.tomls, local suites and the demo run with the source-only resolution policy (WASM_POSIX_RESOLUTION_POLICY=source-only-v1 against the local aggregate output). Nothing in the PR depends on local artifacts — CI rebuilds from source.

At the branch head:

  • apps/browser-demos/test/kandelo-omarchy.spec.tspassed on chromium, firefox and webkit — the Terminal/foot gate, both GLDRAW gates, Gate 5d (Theme Gallery) and Gate 5e (Quickshell; documented firefox exclusion). Firefox passed on a solo rerun; under the three-engine concurrent run it timed out once booting Waybar.
  • Shell-swap done-criterion (Chromium probe): 3 card swaps + 35 s hold, no crash, 13 workers stable — the record for the deliberately-skipped swap gate.
  • cargo test --workspace --target aarch64-apple-darwin --lib1782 passed, 0 failed (includes test_map_fixed_inside_mapping_splits_it)
  • cargo test -p fork-instrument (full, dev shell) → 26 test binaries, 339 passed, 0 failed (includes shared_boundary_node.rs)
  • bash scripts/check-abi-version.shexit 0. The two kernel changes (the MAP_FIXED carve, inotify ENOSYS) move no ABI surface.
  • cd host && npx vitest run, source-only env → 14 files failed, zero new vs the pre-rework baseline (16): the documented env-conflict sets — fixture and package-system tests that assert default-policy behavior — plus the known fork-from-thread defect below.
  • cd host && npx vitest run, default env → 61 files failed, zero new, 8 fixed vs the baseline (69): the incomplete-closure sets from the resolution-env note above; they clear when CI publishes the new-key binaries.
  • bash scripts/run-libc-tests.sh302 PASS, 20 XFAIL, 1 FAIL: regression/raise-race (known defect below). bash scripts/run-posix-tests.shexit 0, only XFAIL/SKIP. Both ran at the gallery commit; the five commits after it touch nothing syscall-adjacent except inotify ENOSYS, which inotify-enosys.test.ts gates.
  • Benchmarks, both hosts, 3 rounds, source-only env: Node fork 102 ms / exec 432 ms / clone 80 ms; browser fork 45 ms / clone 30 ms; WordPress first HTTP response 8.4 s in the browser. Two structural gaps, not skipped measurements: erlang-ring is disabled upstream (DISABLED_SUITES, PR Fix MariaDB and WordPress perf benchmarks #487), and the browser MariaDB suites cannot run under the source-only projection (mariadb-vfs is a dormant browser product in packages/sets/local-supported.toml, inherited from main).

Known defect, inherited from the base branch: raise-race. regression/raise-race — fork from a caught signal handler on a thread; 100 children, ~14 lost, wait returns ECHILD — fails deterministically on this stack and passes on the pre-rebase line. The cause sits below this PR: the rebased stack's commit 9b79d73b4 redesigned signal/park handling (kandelo_caught_handler_depth plus host-owned SA_RESTART records) and dropped the original line's CH_SIG_RESTART channel-word protocol (ea97a4261). Both runtime commits of this PR (1c1db5e20, e40269649) were revert-tested; the failure persists without them. The failing concurrent fork-from-thread test (errno 12) plausibly shares the same machinery. The fix belongs to the stack stage that owns libc/glue and the host signal records, not to this PR.

Per-stage gates, each verified green at its commit:

  • host/test/qt-gui-smoke.test.ts1 passed (QT_GUI_SMOKE_OK on the kernel, plus the QProcess child pass; the undefined-import check stays green)
  • host/test/qt-qml-smoke.test.tspassed (a QML scene renders non-background pixels through the software scenegraph)
  • host/test/qtgallery-smoke.test.ts1 passed (two fixture themes staged, a real pointer click on a card, the compositor's THEME line read back, Escape exits both processes at 0)
  • host/test/quickshell-smoke.test.ts1 passed (quickshell.wasm boots, CLI11 parses argv, --version prints build metadata)
  • host/test/posix-spawn.test.ts, spawn-blob-transport.test.ts, spawn-pid-authority.test.tspassed on the narrowed resolver contract
  • host/test/qtwaylandscanner-host-tool.test.ts2 passed; host/test/wayland-protocols-scanner.test.ts1 passed; tests/package-system/qtbase-package.test.ts9 passed
  • ./run.sh local-build151/151 nodes, 7/7 products with quickshell and cli11 in the registry roots

mho22 added 13 commits September 1, 2026 16:24
…e scanner nixpkgs declines to build

Qt cross-builds run host generators (moc, rcc, uic, qmltyperegistrar, qmlcachegen) and resolve them through QT_HOST_PATH, refusing a host/target version mismatch, so the flake's pinned Qt 6.10.2 fixes the version the recipes may declare. symlinkJoin folds qtbase, qtdeclarative and qtshadertools into the one prefix Qt's CMake requires; ninja and spirv-opt join the dev shell because Qt configures against them.

qtbase builds its qtwaylandscanner only if(LINUX), so nixpkgs' darwin qtbase ships neither the generator nor its Qt6WaylandScannerTools CMake package — a Qt Wayland cross-build on a Mac stops at the missing host tool. The flake builds the generator from qtbase's own src and version, joined into the host tree only where qtbase declines wayland, so Linux keeps its own copy. host/test/qtwaylandscanner-host-tool.test.ts gates both the tool and the generated xdg-shell glue.
…tform plugin

Qt is the first dependency that cannot use the meson bypass — moc/rcc/uic codegen is woven through qtbase — so the recipe drives Qt's own CMake against the flake's host tools. Three target adaptations, each stated in the script: -D__linux__=1 (qsystemdetection.h has no wasm branch), QT_LINUXBASE plus the qmutex patch (Qt honours the switch in qfutex_p.h but not qmutex.h), and an empty <linux/fs.h>. The baked qt_prfxpath is relocated to the guest prefix at install.

Qt 6.10 moved the Wayland client platform plugin into qtbase, so this one package delivers libQt6WaylandClient.a, the wayland QPA plugin and the xdg-shell integration on wl_shm. wayland-shm-gbm-pool.patch backs QWaylandShmBuffer with a renderD128 gbm bo, the pool contract the browser compositor's GL importer requires. forkfd-generic-on-wasm.patch keeps QProcess on forkfd's generic fork() fallback — the __linux__ define would otherwise route it to clone(2), which this kernel does not have, and musl's _Fork.o would duplicate the glue's _Fork at link. The markdown reader pins Qt's bundled md4c so configure cannot link the build machine's dylib. QtWidgets and QtNetwork build too (Quickshell's QuickControls2 styles and src/ipc link them); brotli is forced off because configure detected the nix host copy with no wasm32 bytes behind it.

qt_gui_smoke.cpp gates the stage on the kernel: plugin registry, raster fill, fontconfig/FreeType/HarfBuzz typesetting, and a QProcess re-exec child. The build script rejects any env. import outside the runtime's three. tests/package-system/qtbase-package.test.ts pins the recipe contract; docs/porting-guide.md records the host-generator technique.
…face CMake consumers resolve

libdrm installs a relocatable libdrm.pc plus the linux/asm UAPI shims a __linux__-defining consumer needs, so Qt clients that include drm.h down its Linux branch compile against the installed headers alone. wayland-protocols stages its vendored XML a second time at upstream 1.45's share/wayland-protocols layout and announces it through wayland-protocols.pc, the shape CMake's pkg_get_variable resolves; ext-background-effect, ext-idle-notify and ext-workspace join the vendored set for Quickshell's wlr modules. Both revisions bump.
The MAP_FIXED branch of mmap dropped every mapping that overlapped the requested range, including the parts outside it, so a fixed map placed inside a larger mapping erased the whole mapping from the manager's records and a later first-fit search could hand out addresses the program still used. QtQml hit exactly this: its guard-page mprotect path re-maps a page inside the QML stack area, and the stack got reallocated under the running interpreter.

The branch now routes through munmap, which already splits a mapping and keeps the remainder recorded. test_map_fixed_inside_mapping_splits_it pins the carve.
…gate

qtshadertools cross-builds first because qtdeclarative's CMake hard-requires it, even though the software adaptation never compiles a shader at runtime. qtdeclarative builds QtQml, QtQmlModels and QtQuick with the software scenegraph; qml-network and qml-debug stay on because Quickshell's launcher links QtNetwork unconditionally and calls QQmlDebuggingEnabler unguarded.

qv4-stack-bounds-on-wasm.patch bounds the QV4 engine's stack probes: the engine derives its native stack base from platform heuristics that do not hold on wasm32, and unpatched it walks past the linear-memory stack into the heap. The qt-qml-smoke gate loads a QML scene on the offscreen platform and asserts rendered non-background pixels through QQuickWindow::grabWindow.
…licy

Two source-only gaps. `build-deps path` computed its cache key and cache root under the default policy regardless of the active one, so the printed path pointed at a tier the source-only engine never fills. And the local-build engine did not hand its output root to recipe children: a recipe that boots the kernel host (coreutils-docs runs the real coreutils.wasm) needs WASM_POSIX_SOURCE_ONLY_BINARY_ROOT under the env-scrubbed dev-shell entry, or the resolver refuses the policy env. The engine now exports the root to source-build children; standalone resolve callers keep exporting it themselves. source_only_recipe_receives_engine_binary_root pins the passthrough.
… exactly once

Every SYS_SPAWN preflight compiled its program twice. The resolver (handlePosixSpawnResolve on both hosts) compiled a module that resolveSpawnProgram in the shared worker always discarded, then compileSpawnCandidateSnapshot compiled the same bytes again from its isolated copy. The snapshot compile already carries every check the resolver compile provided.

On Firefox the duplicate is what broke the omarchy desktop: SpiderMonkey reserves one fixed 2 GiB executable-code region per content process for all workers, compiled wasm code is several times the module size (the 93 MB quickshell.wasm costs ~620 MB), and with the desktop's live code at ~1.2 GB the second quickshell compile could not get code memory — InternalError: out of memory, mapped to EIO, surfaced as posix_spawnp: I/O error.

The resolver contract narrows to ResolvedSpawnCandidate — programBytes and argv, no module — on both hosts, so the duplicate cannot come back. The candidate-binding tests now assert byte-identical commits reuse the snapshot module and diverging commits recompile from the committed bytes.
programs/qtdemo/qtdemo.cpp draws an animated wave field, gradient-filled type and a live clock through QtGui's raster engine, the wayland QPA plugin and xdg-shell onto wl_shm — antialiasing the C clients' wpkdraw does not have. Qt's wayland plugin resolves the socket through XDG_RUNTIME_DIR and refuses to start without it.

host/test/qtdemo-smoke.test.ts boots wlcompositor and qtdemo under one NodeKernelHost and reads the program's markers — QTDEMO_PLATFORM=wayland, the first paint, QTDEMO_FRAME at 30 and 60, a non-black composite sample — then a host-injected Escape closes the window through Qt's xkb state and both processes exit 0.
Quickshell is the QtQuick shell toolkit Omarchy v4 writes its bar and launcher in. The build keeps WAYLAND with WLR_LAYERSHELL and turns off every feature whose dependency this kernel does not carry (DBus, PipeWire, PAM, greetd, X11, Hyprland, screencopy, session-lock, toplevel-management, jemalloc, the crash handler). cli11 2.5.0 ships as a header-only package for the launcher's CLI parsing.

no-wl-proxy-interpose-on-wasm.patch removes upstream's wl_proxy_get_listener interposition — a dynamic-linking mechanism (dlsym RTLD_NEXT plus --export-dynamic-symbol, which wasm-ld lacks) that collides with libwayland-client.a's definition in a static link. The executable link closes over libffi, libxml2, libiconv, libcharset and the sysroot gbm — the recipe had declared them without linking them, and the resulting env.ffi_prep_cif trap killed quickshell inside libwayland's closure marshalling before the QML engine started. After the fix the only env imports are the three the platform owns. The quickshell-smoke gate boots the binary on the kernel and asserts --version.
The launcher gains two entries. Qt Demo runs qtdemo — the seventh tile, fontconfig's new sans-serif alias resolving Qt's default family. Quickshell runs the staged /usr/share/kandelo/quickshell/shell.qml; the compositor's environment carries QT_QUICK_BACKEND=software for every client it spawns, and the PanelWindow maps as a wlr-layer-shell clock bar along the bottom edge, where Waybar does not own the exclusive zone. The rootfs superblock capacity doubles to 512 MiB (the demo stages ~200 MB of desktop binaries; the 256 MiB cap died with ENOSPC at exec-time materialization of /usr/bin/dash) and the source-only Vite asset ceiling rises to 1 GiB so the 93 MB quickshell module fits the retained snapshot set.

wlcompositor prints a one-shot GLDRAW app_id= the first time the GL renderer draws a window's own texture, and the spec polls it for foot and qtdemo: every protocol marker (map, focus, tile, frame callback) still fires for a window whose wl_shm pool the GPU cannot import, which is exactly how an invisible qtdemo first shipped. Gate 5e launches Quickshell through the launcher and takes the LAYER ns=quickshell line as the mapping proof — layer surfaces never emit GLDRAW. Firefox is excluded with a pointer to the new browser-support section: the running desktop's compiled wasm plus Quickshell's main and thread-patched modules exceeds SpiderMonkey's fixed 2 GiB per-process executable-code arena, so its first QThread::start fails. The section records the measured numbers.
perl is a multi-output package, so its binary lands at programs/perl/perl.wasm; run-example.ts still probed the flat programs/perl.wasm and silently skipped the perl examples.
Upstream libdrm's drmHandleEvent does a single read and assumes Linux's blocking read on /dev/dri/cardN parks the caller until the vblank pump delivers an event. This kernel returns 0 immediately on an empty event ring, so a bare drmHandleEvent races the vblank tick: it returns without dispatching, the queued flip stays pending, and the next drmModePageFlip hits EBUSY. Real consumers (SDL2 KMSDRM, weston, mutter) poll first; the fixture now does the same.
… runtime file

The shell package's build.toml moved to revision 32 and shell-lazy-archives grew NCURSES_TERMINFO_RUNTIME_FILE, but the bridge test still asserted revision 30 and staged no terminfo member, so it failed against the tree it verifies. The fixture now writes the terminfo archive the resolver contract names and the revision assertion follows the manifest.
@mho22
mho22 force-pushed the explore-dri-quattro branch from 36d9da7 to 076a470 Compare September 1, 2026 14:27
…the desktop restyles

qtdemo proved the Qt-on-wl_shm pipeline with a wave field and a clock; it gave the visitor nothing to do. qtgallery makes the whole desktop the demo. It reads every installed theme (a directory under /usr/share/kandelo/themes with a theme.conf) and paints each as a card — a miniature desktop rendered from that theme's own palette: wallpaper gradient, bar strip, two tiled windows with the active border. Clicking a card writes `dispatch theme <name>` to the compositor's kwlctl socket, the same control surface CTRL+SHIFT+Space and hyprctl use, and the whole desktop repaints from the new palette. The app is data-driven: drop a theme directory in the VFS and its card appears. Cards activate by click (the pointer path Qt clients had never exercised — verified end to end) or by arrows and Enter.

A second card row lists the staged Quickshell shells (shell.qml, island.qml, status.qml). Activating one restarts the gallery's Quickshell child with that QML file through QProcess — the swap-the-shell demonstration Quickshell exists for.

The gate moves from qtdemo to qtgallery on both hosts. host/test/qtgallery-smoke.test.ts boots wlcompositor and qtgallery under one NodeKernelHost, stages two fixture themes, injects a real pointer click on the first card, and reads the compositor's THEME line back — the click-to-restyle loop proven end to end, then Escape closes and both processes exit 0. The browser Gate 5d launches Theme Gallery through the launcher and asserts GALLERY_THEMES n=6, the seventh tile, and GLDRAW app_id=qtgallery (the invisible-window gate: the window's texture reached the GL renderer, not just protocol markers).

Two guest-side Qt limits bound the staged shells, documented with repros in docs/browser-support.md#quickshell-qml-limits. A bo-lifecycle trace of the crash showed the compositor holding a constant four buffer objects, so the growth is inside Qt, not the DRI/gbm/compositor path — the earlier presenter-path attribution was wrong. First: Qt's QML software renderer allocates without bound when a scene repaints every frame (a multi-item PanelWindow, a Qt.formatDateTime name field, or font.bold), and the process is killed within seconds; a single-item panel with numeric date fields at the regular weight is stable. Second: each quickshell.wasm spawn is a ~620 MB Qt compile, so a first shell swap is reliable but repeated swaps accumulate — the executable-code cost already documented for Firefox, reached through respawns. The three shells are authored inside that stable envelope. Both are lower-stack fixes, not addressed here.
…y needs

Call-heavy Qt functions came out of instrumentation too large for TurboFan's register allocator, and giant functions made the liveness pass itself blow up. Four bounds fix both, without changing what a frame can replay.

Scalar liveness now decides what a frame saves. Only locals live on any successor of a fork landing — plus parameters and the scalar inputs of pure-replay tails — round-trip through the frame; a local dead at every resume point costs no frame bytes and no save/restore code. The analysis reuses the reference pass's structured CFG (exception edges included) and both passes now share one bitset-based dataflow: a BTreeSet per node across thousands of locals and tens of thousands of nodes costs gigabytes, one bit per tracked local costs megabytes.

Spill locals come from one function-wide pool keyed by storage type and per-landing ordinal. A landing's spills are written at its own chunk tail and read back at its own landing with only the callee in between, so windows never overlap inside an activation and every landing can share the same locals. Instrument-added locals are bounded by the widest single landing instead of the landing count.

From SHARED_UNWIND_BOUNDARY_MIN_CALLS (8) fork-path calls, a function trades the per-site zero-extra-local unwind boundaries (kept below the threshold — they preserve recursion depth, PR #713) for one selector local: each site writes its static call index and catches the private tag to a single shared frame-select handler, replacing roughly fifteen boundary instructions per site. Two smaller emission bounds follow the same goal: a direct-activation landing emits its materialized call once for both NORMAL and replay, and the reference save/restore dispatch emits one body per distinct (slots, nulls) set guarded by the union of its ranges instead of one body per run.

tests/shared_boundary_node.rs runs an instrumented nine-landing fixture in Node through a real unwind/rewind and an abort-restart with an injected reservation failure; switch_dispatch pins that a dead scalar stays out of the frame; large_dispatcher pins the shared-boundary shape; the fork-abort-unwind fixtures read every local after the call site so the liveness filter keeps their payloads. K-02 and P-04 were slow under load, not hung — both get the 10 s timeout K-03 already has.
…uccess

inotify_init returned a real pollable fd (an eventfd in disguise) that never delivers events, and add/rm_watch pretended to succeed. That combination defeats every watcher that only takes its polling fallback when inotify_init fails — Qt's QFileSystemWatcher and glib's GFileMonitor trusted the dead fd and never saw a change. All three entry points now return ENOSYS, the stub eventfd helper and its tests go away, and posix-status.md finally matches the behavior it already claimed. programs/inotify-enosys.c asserts errno for each entry point through the real syscall path.
… so shrink and stop respawning

Chromium compiles a wasm module's machine code once per Web Worker — a worker is a separate V8 isolate, and isolates do not share compiled code even for the same posted WebAssembly.Module. Every guest pthread is one worker, so each thread a Qt process starts costs another full compiled copy of quickshell.wasm (~93 MB of wasm) on top of the running desktop. Everything here attacks that one term.

qtbase, qtdeclarative, and quickshell build MinSizeRel instead of Release: code size multiplies across workers, so size beats speed. qtdeclarative turns qml-type-loader-thread off, taking Qt's supported synchronous type-loading path (the same shape as upstream's single-threaded wasm port) — the loader thread cost a full compiled copy of every QML consumer and its per-reload churn spiked tab memory on each Quickshell config reload. qtbase gains a wayland-fd-notifier patch and quickshell gains an on-thread logger patch, both removing threads that existed only to block on fds. Revisions bump where output bytes change.

quickshell's one-generation-reload patch and qtgallery's rework retire the respawn path entirely: the gallery now stages the selected shell as /tmp/qtgallery-active.qml through QSaveFile (the atomic commit keeps Quickshell's content-hash reload from reading a half-written file) and one long-lived Quickshell child live-reloads it in place. One child for every swap, never a respawn — respawn churn at this module size crashed the tab before the previous copy's code memory was reclaimed. Verified in Chromium: three shell-card swaps plus a 35 s hold, no crash, worker count stable at 13.
…auncher that scrolls

The launcher listed three terminals — Terminal (wlterm), Bash (wlterm bash -i), Foot — when one is the product: the entry is now Terminal and it execs stock upstream foot, the strongest of the three (an unmodified Wayland client through fontconfig and fcft). Eight entries remain. The old separate Foot gate folds into the Terminal gate, which keeps the ABI and GLDRAW assertions; the qtgallery and quickshell tile indices shift down accordingly. Quickshell's launcher entry runs island.qml — a floating clock island reads better over the wallpaper than a full-width bar under Waybar's.

klauncher gains a scroll offset clamped to the surface height in render() and reset on level entry. Rows past the window bottom were silently undrawn, which made Theme Gallery unreachable by arrow keys at ten entries.

docs/browser-support.md follows: the Terminal-entry passage, island.qml as the staged default, and the quickshell-qml-limits section rewritten around the measured per-worker compile cost — the earlier guest-defect attribution (unbounded software-renderer allocation, respawn accumulation) is replaced by the two facts measurement established: single-threading the Qt client roughly halved the baseline, and QML content decides how much cold Qt/ICU code each worker compiles.
…clared dependency

generate-coreutils-man.ts ran coreutils inside Kandelo by resolving kernel.wasm through the checkout-wide binary resolver — but a package build runs inside the aggregate, and the source-only projection authority the resolver would consult is only finalized after every package (including this one) has succeeded. The kernel is now a declared package dependency: package.toml adds kernel@0.1.0, the build script passes the dependency dir's kandelo-kernel.wasm to the capture script explicitly, and the test helper accepts kernel bytes as an option instead of always resolving them itself.
@mho22 mho22 changed the title Quickshell on Kandelo — Qt 6.10 on wasm32: QtCore, QtGui, QML, and a QtQuick bar on the desktop Quickshell on Kandelo — Qt 6.10 on wasm32: QtCore, QtGui, QML, and a theme gallery that restyles the desktop Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant